SpanDurationMetricRecorder Class

Namespace: Diginsight.Diagnostics
Assembly: Diginsight.Diagnostics.dll

Automatically records span duration metrics for .NET activities, integrating with OpenTelemetry’s metrics pipeline.

public sealed class SpanDurationMetricRecorder : IActivityListenerLogic

Inheritance

Object ? SpanDurationMetricRecorder

Implements

  • IActivityListenerLogic

Summary

The SpanDurationMetricRecorder class is the core component responsible for automatically collecting duration metrics from .NET activities. It listens to activity lifecycle events and records the execution time of operations as OpenTelemetry histograms, enabling performance monitoring and analysis across your application.

Key capabilities: - ? Automatic metric collection when activities complete - ? Configurable filtering via IMetricRecordingFilter - ? Tag enrichment via IMetricRecordingEnricher - ? Class-aware options for fine-grained control - ? Exception safety - failures don’t affect application flow - ? OpenTelemetry integration - exports to any OTEL-compatible backend


Constructors

SpanDurationMetricRecorder(ILogger, IClassAwareOptions, IMeterFactory, IMetricRecordingFilter?, IMetricRecordingEnricher?)

Initializes a new instance of the SpanDurationMetricRecorder class.

public SpanDurationMetricRecorder(
    ILogger<SpanDurationMetricRecorder> logger,
    IClassAwareOptions<DiginsightActivitiesOptions> activitiesOptions,
    IMeterFactory meterFactory,
    IMetricRecordingFilter? recordingFilter = null,
    IMetricRecordingEnricher? recordingEnricher = null
)

Parameters

logger : ILogger<SpanDurationMetricRecorder>
Logger for diagnostic messages and error handling.

activitiesOptions : IClassAwareOptions<DiginsightActivitiesOptions>
Configuration options controlling metric recording behavior, including meter name, metric name, and recording flags.

meterFactory : IMeterFactory
Factory for creating OpenTelemetry meters and instruments.

recordingFilter : IMetricRecordingFilter? (optional)
Optional filter to control which activities should record metrics. If null, filtering is based on configuration only.

recordingEnricher : IMetricRecordingEnricher? (optional)
Optional enricher to add custom tags to metrics. If null, only default tags (span_name, status) are included.

Remarks

The histogram metric is created lazily on first use to avoid unnecessary overhead. The metric uses the meter name and metric name from DiginsightActivitiesOptions, defaulting to "diginsight.span_duration".


Methods

ActivityStopped(Activity)

Called when an activity stops, recording its duration as a metric.

void IActivityListenerLogic.ActivityStopped(Activity activity)

Parameters

activity : Activity
The activity that has stopped.

Remarks

This method performs the following operations: 1. Retrieves the histogram from the lazy-initialized meter 2. Checks filtering rules via IMetricRecordingFilter or configuration 3. Builds tag array with span_name, status, and enriched tags 4. Records duration in milliseconds to the histogram 5. Handles exceptions gracefully with warning logs

The method has minimal performance overhead when recording is disabled due to early exit conditions.

Example

This method is called automatically by the .NET ActivityListener infrastructure:

// Your application code
using var activity = ActivitySource.StartMethodActivity(new { orderId = 123 });
await ProcessOrderAsync(orderId);
// Activity stops here, triggering ActivityStopped
// Metric recorded: diginsight.span_duration{span_name="ProcessOrderAsync", status="Ok"} = 250ms

Sample(ref ActivityCreationOptions)

Determines the sampling behavior for new activities.

ActivitySamplingResult IActivityListenerLogic.Sample(
    ref ActivityCreationOptions<ActivityContext> creationOptions
)

Returns

ActivitySamplingResult
Always returns ActivitySamplingResult.AllData to ensure full activity data is available for metric collection.

Parameters

creationOptions : ActivityCreationOptions<ActivityContext>
The options for creating the activity (passed by reference).

Remarks

This method ensures that all activities have complete data (duration, tags, status) needed for accurate metric recording. The recorder does not participate in sampling decisions - filtering happens at metric recording time via IMetricRecordingFilter.


ActivityStarted(Activity)

Called when an activity starts (no-op for this recorder).

void IActivityListenerLogic.ActivityStarted(Activity activity)

Parameters

activity : Activity
The activity that has started.

Remarks

This method has an empty implementation because span duration metrics only need to be recorded when activities complete. The method only exists in .NET Framework and .NET Standard 2.0 builds due to conditional compilation.


Usage Examples

Basic Registration

Register the recorder during application startup:

var builder = WebApplication.CreateBuilder(args);

// Register span duration metric recorder
builder.Services.AddSpanDurationMetricRecorder();

// Configure OpenTelemetry to export metrics
builder.Services.AddOpenTelemetry()
    .WithMetrics(metrics => metrics
        .AddMeter("Diginsight.Diagnostics")
        .AddPrometheusExporter());

var app = builder.Build();
app.Run();

Configuration via appsettings.json

{
  "Diginsight": {
    "Activities": {
      "RecordSpanDuration": true,
      "SpanDurationMeterName": "MyApp.Telemetry",
      "SpanDurationMetricName": "operation.duration",
      "SpanDurationMetricDescription": "Duration of application operations",
      "ActivitySources": {
        "MyApp.*": true,
        "System.*": false
      }
    }
  }
}

With Filtering

Register with a custom filter to control which activities record metrics:

builder.Services.AddSpanDurationMetricRecorder();

// Add filter to only record slow operations
builder.Services.AddSingleton<IMetricRecordingFilter, SlowOperationFilter>();

public class SlowOperationFilter : IMetricRecordingFilter
{
    public bool? ShouldRecord(Activity activity, Instrument instrument)
    {
        // Only record operations longer than 100ms
        return activity.Duration.TotalMilliseconds >= 100;
    }
}

With Enrichment

Add business context tags to metrics:

builder.Services.AddSpanDurationMetricRecorder();
builder.Services.AddSingleton<IMetricRecordingEnricher, BusinessContextEnricher>();

public class BusinessContextEnricher : IMetricRecordingEnricher
{
    public Tags ExtractTags(Activity activity, Instrument instrument)
    {
        var tags = new List<Tag>();
        
        // Add deployment environment
        tags.Add(new Tag("environment", 
            Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")));
        
        // Extract customer tier from activity
        if (activity.GetTagItem("customer_tier") is string tier)
            tags.Add(new Tag("customer.tier", tier));
        
        return tags;
    }
}

Application Code

Activities automatically generate metrics:

public class OrderService
{
    private static readonly ActivitySource ActivitySource = new("MyApp.Orders");
    
    public async Task<Order> ProcessOrderAsync(int orderId)
    {
        // Activity with rich context
        using var activity = ActivitySource.StartRichActivity("ProcessOrder", new
        {
            orderId,
            customer_tier = "premium",  // Will be included if enricher configured
            region = "us-east"
        });
        
        try
        {
            var order = await GetOrderFromDatabase(orderId);
            await ValidateInventory(order);
            await ProcessPayment(order);
            
            activity?.SetOutput(new { order.Id, order.Status });
            return order;
        }
        catch (Exception ex)
        {
            activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
            throw;
        }
        // Metric automatically recorded when activity disposes:
        // operation.duration{
        //   span_name="ProcessOrder",
        //   status="Ok",
        //   customer.tier="premium",
        //   environment="Production"
        // } = 250ms
    }
}

Viewing Metrics in Prometheus

# Average order processing time
avg(operation_duration{span_name="ProcessOrder"})

# 95th percentile by customer tier
histogram_quantile(0.95, 
  rate(operation_duration_bucket{customer_tier="premium"}[5m]))

# Error rate
rate(operation_duration{status="Error"}[5m]) / 
rate(operation_duration[5m])

Viewing Metrics in Application Insights

// Average duration by operation
customMetrics
| where name == "operation.duration"
| summarize avg(value) by tostring(customDimensions.span_name)
| order by avg_value desc

// 95th percentile for slow operations
customMetrics
| where name == "operation.duration"
| summarize percentile(value, 95) by tostring(customDimensions.span_name)

Configuration

DiginsightActivitiesOptions Properties

The recorder is configured through DiginsightActivitiesOptions:

Property Type Default Description
RecordSpanDuration bool false Master switch to enable/disable metric recording
SpanDurationMeterName string? null OpenTelemetry meter name (required if recording enabled)
SpanDurationMetricName string? "diginsight.span_duration" Metric name in telemetry system
SpanDurationMetricDescription string? null Human-readable metric description
ActivitySources Dictionary<string, bool> {} Pattern-based filter for activity sources

Filtering Logic

The recorder decides whether to record a metric using this precedence:

  1. IMetricRecordingFilter.ShouldRecord() - if registered and returns non-null
  2. DiginsightActivitiesOptions.RecordSpanDuration - global flag from configuration
  3. Skip recording if both are false/null
if (!(recordingFilter?.ShouldRecord(activity, metric) ?? metricOptions.Record))
    return; // Skip recording

Example filter combinations:

Filter Return Config Value Result
true false ? Record (filter overrides)
false true ? Skip (filter overrides)
null true ? Record (uses config)
null false ? Skip (uses config)

Tag Structure

Default Tags

Every metric includes these tags:

  • span_name : string - The activity’s OperationName
  • status : string - Activity status (“Ok”, “Error”, “Unset”)

Enriched Tags

Additional tags from IMetricRecordingEnricher.ExtractTags():

Tag[] tags = recordingEnricher is not null
    ? [ nameTag, statusTag, .. recordingEnricher.ExtractTags(activity, metric) ]
    : [ nameTag, statusTag ];

Example metric output:

operation.duration{
  span_name="ProcessOrder",
  status="Ok",
  customer.tier="premium",
  region="us-east",
  environment="Production"
} = 250ms

Performance Considerations

Lazy Metric Creation

The histogram is created only when first needed:

private readonly Lazy<Histogram<double>> metricLazy;

// Created once on first ActivityStopped call
Histogram<double> metric = metricLazy.Value;

Benefits: - ? No overhead if recording is disabled - ? Deferred initialization until configuration is available - ? Thread-safe creation

Early Exit Optimization

Fast filtering before expensive operations:

// Check filter first (microseconds)
if (!(recordingFilter?.ShouldRecord(activity, metric) ?? metricOptions.Record))
    return; // Skip tag building and metric recording

// Only if recording is needed (milliseconds)
Tag[] tags = BuildTags(activity);
metric.Record(activity.Duration.TotalMilliseconds, tags);

Exception Safety

Recording failures don’t crash the application:

try
{
    metric.Record(activity.Duration.TotalMilliseconds, tags);
}
catch (Exception exception)
{
    logger.LogWarning(exception, "Unhandled exception while recording...");
    // Application continues normally
}

Memory Efficiency

  • Tag arrays are allocated only when recording
  • Configuration options are frozen to prevent modifications
  • No memory retention between recordings

Integration with OpenTelemetry

Meter Registration

The recorder creates a meter using IMeterFactory:

var meter = meterFactory.Create(metricOptions.MeterName);
var histogram = meter.CreateHistogram<double>(
    metricOptions.MetricName, 
    "ms", 
    metricOptions.MetricDescription);

Histogram Properties

  • Type: Histogram<double>
  • Unit: "ms" (milliseconds)
  • Value: activity.Duration.TotalMilliseconds
  • Aggregation: Supports percentiles (P50, P95, P99) and counts

Exporter Configuration

Metrics flow through OpenTelemetry’s standard pipeline:

builder.Services.AddOpenTelemetry()
    .WithMetrics(metrics => metrics
        .AddMeter("MyApp.Telemetry")           // Listen to your meter
        .AddPrometheusExporter()               // Export to Prometheus
        .AddApplicationInsightsExporter()      // Export to Azure
        .AddOtlpExporter());                   // Export via OTLP

Thread Safety

The SpanDurationMetricRecorder is thread-safe:

  • ? Lazy initialization is thread-safe by design
  • ? Histogram.Record() is thread-safe per OpenTelemetry spec
  • ? No mutable state between recordings
  • ? Options are frozen before use

Multiple activities can complete concurrently without synchronization issues.


Troubleshooting

No Metrics Appearing

Symptoms: Metrics don’t show up in your monitoring backend.

Checklist: 1. ? Is RecordSpanDuration = true in configuration? 2. ? Is the meter name registered in OpenTelemetry? 3. ? Are activity sources matched by configuration patterns? 4. ? Is an exporter configured and running? 5. ? Are activities actually being created and stopped?

Diagnostic code:

var options = serviceProvider.GetService<IOptions<DiginsightActivitiesOptions>>();
Console.WriteLine($"RecordSpanDuration: {options.Value.RecordSpanDuration}");
Console.WriteLine($"MeterName: {options.Value.SpanDurationMeterName}");

High Cardinality Warning

Symptoms: Excessive storage costs or query performance issues.

Cause: Too many unique tag combinations (high cardinality).

Solution: Use IMetricRecordingEnricher to bucket high-cardinality values:

public class CardinalityControlEnricher : IMetricRecordingEnricher
{
    public Tags ExtractTags(Activity activity, Instrument instrument)
    {
        var tags = new List<Tag>();
        
        // ? Bad: customer_id has millions of values
        // tags.Add(new Tag("customer_id", activity.GetTagItem("customer_id")));
        
        // ? Good: customer_tier has only 3 values
        tags.Add(new Tag("customer.tier", activity.GetTagItem("customer_tier")));
        
        // ? Good: bucket order values
        if (activity.GetTagItem("order_value") is double value)
        {
            var bucket = value switch
            {
                < 50 => "small",
                < 200 => "medium",
                < 1000 => "large",
                _ => "enterprise"
            };
            tags.Add(new Tag("order.value_bucket", bucket));
        }
        
        return tags;
    }
}

Performance Impact

Symptoms: Increased CPU usage or latency.

Mitigation: 1. Use aggressive filtering to reduce recorded activities 2. Limit enricher complexity - avoid heavy computations 3. Monitor tag cardinality - keep unique combinations low 4. Consider sampling for high-throughput scenarios

public class SamplingFilter : IMetricRecordingFilter
{
    private int counter = 0;
    
    public bool? ShouldRecord(Activity activity, Instrument instrument)
    {
        // Record only 1 in 10 activities
        return Interlocked.Increment(ref counter) % 10 == 0;
    }
}

Version History

Version Changes
3.0.0 Initial release with IMetricRecordingFilter and IMetricRecordingEnricher support
3.1.0 Added class-aware options support for per-component configuration
3.2.0 Improved exception handling and logging

See Also


Remarks

The SpanDurationMetricRecorder is a foundational component of Diginsight’s observability stack, providing automatic, zero-configuration metric collection that integrates seamlessly with OpenTelemetry. By combining it with filters and enrichers, you can create sophisticated metrics pipelines tailored to your specific monitoring needs while maintaining high performance and low overhead.

Design principles: - ?? Zero-impact by default - no overhead when recording is disabled - ?? Highly extensible - filter and enricher extension points - ?? Exception-safe - never crashes your application - ?? Standards-based - uses OpenTelemetry conventions - ? Performance-optimized - lazy initialization and early exit

Back to top